在過去幾年的測試開發經驗中,我一直是 Fluent Assertions 的忠實使用者。它的流暢語法、豐富的 Assertions 方法,以及優秀的錯誤訊息,讓測試程式碼變得更加易讀和維護。
以前介紹 Fluent Assertions 的文章
隨著測試驅動開發在企業中的普及,選擇合適的 Assertions Library 成為了重要的技術決策。AwesomeAssertions 作為一個功能強大且完全免費的開源解決方案,提供了與 Fluent Assertions 相似的開發體驗。
今天我們將深入探討:
2025年,Fluent Assertions 宣布了授權模式的重大變化:
個人開發者與開源專案:
企業與商業專案:
現有專案的困境:
相關連結:
AwesomeAssertions 是一個現代化的 .NET 測試 Assertions Library,設計目標是提供與 Fluent Assertions 相似的開發體驗,同時保持完全開源和免費。
關於 AwesomeAssertions:
AwesomeAssertions 是 FluentAssertions 的社群分支版本,使用 Apache 2.0 授權。本章節使用 AwesomeAssertions 9.1.0 版本,該版本的 API 與 FluentAssertions 高度相容。
# 使用 Package Manager Console
Install-Package AwesomeAssertions -Version 9.1.0
# 使用 .NET CLI
dotnet add package AwesomeAssertions --version 9.1.0
# 使用 PackageReference (推薦)
<PackageReference Include="AwesomeAssertions" Version="9.1.0" PrivateAssets="all" />
using AwesomeAssertions;
using Xunit;
namespace MyProject.Tests
{
public class BasicAssertionTests
{
[Fact]
public void FirstAwesomeAssertion_應該正常運作()
{
var result = "Hello World";
// 使用 AwesomeAssertions 的流暢語法
result.Should().NotBeNull()
.And.StartWith("Hello")
.And.EndWith("World")
.And.HaveLength(11);
}
}
}
public class ObjectAssertionTests
{
[Fact]
public void ObjectAssertion_基本檢查_應該正常運作()
{
var user = new User { Id = 1, Name = "John", Email = "john@example.com" };
var nullUser = (User)null;
// 基本物件 Assertions
user.Should().NotBeNull();
user.Should().BeOfType<User>();
user.Should().BeAssignableTo<IUser>();
// 空值 Assertions
nullUser.Should().BeNull();
// 相等性 Assertions
var anotherUser = new User { Id = 1, Name = "John", Email = "john@example.com" };
user.Should().BeEquivalentTo(anotherUser);
}
[Fact]
public void ObjectAssertion_屬性檢查_應該正常運作()
{
var user = new User { Id = 1, Name = "John", Email = "john@example.com" };
// 屬性值 Assertions
user.Id.Should().Be(1);
user.Name.Should().NotBeNullOrEmpty()
.And.StartWith("J")
.And.HaveLength(4);
user.Email.Should().Contain("@")
.And.EndWith(".com");
}
}
public class StringAssertionTests
{
[Theory]
[InlineData("Hello World", "Hello", "World")]
[InlineData("Test@Example.com", "@", ".com")]
public void StringAssertion_內容檢查_應該正常運作(string input, string start, string end)
{
input.Should().NotBeNullOrEmpty()
.And.StartWith(start)
.And.EndWith(end)
.And.Contain("@")
.And.HaveLength(16);
}
[Fact]
public void StringAssertion_模式匹配_應該正常運作()
{
var email = "user@example.com";
var phoneNumber = "+1-555-123-4567";
// 正規表達式 Assertions
email.Should().MatchRegex(@"^[^@]+@[^@]+\.[^@]+$");
// 格式 Assertions
phoneNumber.Should().StartWith("+")
.And.Contain("-")
.And.HaveLength(15);
}
[Fact]
public void StringAssertion_忽略大小寫_應該正常運作()
{
var text = "Hello World";
// 忽略大小寫的比較
text.Should().BeEquivalentTo("hello world", StringComparison.OrdinalIgnoreCase);
text.Should().StartWith("HELLO", StringComparison.OrdinalIgnoreCase);
}
}
public class NumericAssertionTests
{
[Theory]
[InlineData(10, 5, 15)]
[InlineData(0, -1, 1)]
[InlineData(-5, -10, 0)]
public void NumericAssertion_範圍檢查_應該正常運作(int value, int min, int max)
{
value.Should().BeGreaterThan(min)
.And.BeLessThan(max)
.And.BeInRange(min, max);
}
[Fact]
public void NumericAssertion_浮點數處理_應該正常運作()
{
var pi = 3.14159;
var approximatePi = 3.14;
// 浮點數精度 Assertions
pi.Should().BeApproximately(3.14, 0.01);
approximatePi.Should().BeCloseTo(pi, 0.01);
// 特殊值 Assertions
double.NaN.Should().BeNaN();
double.PositiveInfinity.Should().BePositiveInfinity();
}
[Fact]
public void NumericAssertion_計算結果_應該正常運作()
{
var calculator = new Calculator();
// 計算結果 Assertions
calculator.Add(2, 3).Should().Be(5);
calculator.Divide(10, 3).Should().BeApproximately(3.33, 0.01);
calculator.Multiply(0, 100).Should().Be(0);
}
}
public class CollectionAssertionTests
{
[Fact]
public void CollectionAssertion_基本檢查_應該正常運作()
{
var numbers = new[] { 1, 2, 3, 4, 5 };
var emptyList = new List<int>();
// 基本集合 Assertions
numbers.Should().NotBeEmpty()
.And.HaveCount(5)
.And.Contain(3)
.And.NotContain(10);
emptyList.Should().BeEmpty()
.And.HaveCount(0);
}
[Fact]
public void CollectionAssertion_順序與唯一性_應該正常運作()
{
var sortedNumbers = new[] { 1, 2, 3, 4, 5 };
var unsortedNumbers = new[] { 3, 1, 4, 2, 5 };
var duplicateNumbers = new[] { 1, 2, 2, 3, 3, 3 };
// 順序 Assertions
sortedNumbers.Should().BeInAscendingOrder();
unsortedNumbers.Should().NotBeInAscendingOrder();
// 唯一性 Assertions
sortedNumbers.Should().OnlyHaveUniqueItems();
duplicateNumbers.Should().ContainDuplicates();
}
[Fact]
public void CollectionAssertion_複雜物件_應該正常運作()
{
var users = new[]
{
new User { Id = 1, Name = "Alice", Age = 25 },
new User { Id = 2, Name = "Bob", Age = 30 },
new User { Id = 3, Name = "Charlie", Age = 35 }
};
// 複雜物件集合 Assertions
users.Should().HaveCount(3)
.And.Contain(u => u.Name == "Alice")
.And.AllSatisfy(u => u.Age.Should().BeGreaterThan(20));
// 投影 Assertions
users.Select(u => u.Name).Should().Contain("Bob")
.And.NotContain("David");
users.Where(u => u.Age > 30).Should().HaveCount(1);
}
}
public class ExceptionAssertionTests
{
[Fact]
public void Exception_基本檢查_應該正常運作()
{
var userService = new UserService();
// 基本例外 Assertions
Action action = () => userService.GetUser(-1);
action.Should().Throw<ArgumentException>()
.WithMessage("User ID must be positive*")
.And.ParamName.Should().Be("userId");
}
[Fact]
public void Exception_不應拋出例外_應該正常運作()
{
var calculator = new Calculator();
// 確保不拋出例外
Action action = () => calculator.Add(1, 2);
action.Should().NotThrow();
}
[Fact]
public void Exception_特定例外類型_應該正常運作()
{
var service = new ValidationService();
// 驗證特定例外類型
Action action = () => service.ValidateEmail("");
action.Should().Throw<ArgumentException>()
.WithMessage("*email*");
}
}
public class AsyncAssertionTests
{
[Fact]
public async Task AsyncAssertion_任務完成_應該正常運作()
{
var userService = new UserService();
// Task 完成狀態 Assertions
var task = userService.GetUserAsync(1);
await task; // 等待完成
task.Should().BeCompletedSuccessfully();
task.Result.Should().NotBeNull();
task.Result.Id.Should().Be(1);
}
[Fact]
public async Task AsyncAssertion_例外處理_應該正常運作()
{
var apiService = new ApiService();
// 非同步方法例外 Assertions
Func<Task> asyncAction = () => apiService.GetDataAsync("invalid-endpoint");
await asyncAction.Should().ThrowAsync<HttpRequestException>()
.WithMessage("*404*");
}
}
public class ObjectComparisonTests
{
[Fact]
public void ObjectComparison_深度比較_應該正常運作()
{
var expectedUser = new User
{
Id = 1,
Name = "John Doe",
Email = "john@example.com",
Profile = new UserProfile
{
Age = 30,
City = "New York"
}
};
var actualUser = userService.GetUser(1);
// 深度物件比較
actualUser.Should().BeEquivalentTo(expectedUser);
}
[Fact]
public void ObjectComparison_排除特定屬性_應該正常運作()
{
var user = userService.CreateUser("john@example.com", "John Doe");
// 排除特定屬性
user.Should().BeEquivalentTo(expectedUser, options =>
options.Excluding(u => u.Id) // 排除自動生成的 ID
.Excluding(u => u.CreatedAt) // 排除時間戳記
);
}
}
public class ComplexObjectComparisonTests
{
[Fact]
public void ComplexObject_完整比較_應該正常運作()
{
var expectedOrder = new Order
{
Id = 1,
CustomerName = "John Doe",
OrderDate = new DateTime(2024, 1, 15),
Items = new[]
{
new OrderItem { ProductId = 1, ProductName = "Laptop", Quantity = 1, Price = 999.99m },
new OrderItem { ProductId = 2, ProductName = "Mouse", Quantity = 2, Price = 25.50m }
},
ShippingAddress = new Address
{
Street = "123 Main St",
City = "Anytown",
ZipCode = "12345"
}
};
var actualOrder = orderService.GetOrder(1);
// 完整深度物件比較
actualOrder.Should().BeEquivalentTo(expectedOrder);
}
[Fact]
public void ComplexObject_部分屬性比較_應該正常運作()
{
var user = userService.CreateUser("john@example.com", "John Doe");
// 部分屬性比較
user.Should().BeEquivalentTo(new
{
Email = "john@example.com",
Name = "John Doe",
IsActive = true
}, options => options.ExcludingMissingMembers());
}
}
public class UserServiceTests
{
// 推薦:清楚的測試命名遵循 [方法]_[情境]_[預期結果] 模式
[Fact]
public void CreateUser_有效資料_應該回傳啟用使用者()
{
// Arrange
var userData = new CreateUserRequest
{
Email = "test@example.com",
Name = "Test User"
};
// Act
var result = userService.CreateUser(userData);
// Assert
result.Should().NotBeNull()
.And.BeOfType<User>();
result.Email.Should().Be(userData.Email);
result.IsActive.Should().BeTrue();
}
[Theory]
[InlineData("", "Name cannot be empty")]
[InlineData(null, "Name cannot be null")]
[InlineData("A", "Name must be at least 2 characters")]
public void CreateUser_無效名稱_應該拋出參數例外(string invalidName, string expectedError)
{
var userData = new CreateUserRequest { Email = "test@example.com", Name = invalidName };
Action action = () => userService.CreateUser(userData);
action.Should().Throw<ArgumentException>()
.WithMessage(expectedError);
}
}
public class ReadableAssertionTests
{
[Fact]
public void ProcessOrder_處理訂單_應該計算正確總額()
{
var order = orderService.ProcessOrder(sampleItems);
// 推薦:鏈式 Assertions ,提高可讀性
order.Should().NotBeNull("因為訂單處理不應該回傳 null")
.And.BeOfType<Order>("因為結果應該是有效的 Order 物件");
// 分組相關的 Assertions
order.Items.Should().HaveCount(3, "因為我們提供了 3 個項目")
.And.AllSatisfy(item =>
{
item.Price.Should().BeGreaterThan(0, "因為所有項目都必須有正數價格");
item.Quantity.Should().BeGreaterThan(0, "因為數量必須是正數");
});
// 明確的期望值 Assertions
order.TotalAmount.Should().Be(expectedTotal,
"因為總額應該是所有項目價格乘以數量的總和");
}
}
public class DomainSpecificAssertionPatterns
{
[Fact]
public void ValidateUser_使用者驗證_應該符合業務規則()
{
var user = userService.CreateUser("john@example.com", "John Doe");
// 業務規則驗證模式
user.Should().NotBeNull();
user.Email.Should().MatchRegex(@"^[^@]+@[^@]+\.[^@]+$",
"因為電子郵件應該符合有效格式");
user.Name.Should().NotBeNullOrWhiteSpace()
.And.HaveLength(length => length >= 2 && length <= 50,
"因為名稱長度應該在 2 到 50 個字元之間");
user.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1),
"因為使用者建立時間應該是最近的時間");
}
[Fact]
public void ValidateApiResponse_API回應驗證_應該符合規格()
{
var response = apiClient.GetUserProfile(userId);
// API 回應驗證模式
response.Should().NotBeNull();
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Data.Should().NotBeNull()
.And.BeOfType<UserProfile>();
// 回應時間驗證
response.ResponseTime.Should().BeLessThan(TimeSpan.FromSeconds(2),
"因為 API 回應時間應該在可接受範圍內");
}
}
public class AssertionStyleComparison
{
[Fact]
public void TraditionalVsFluentAssertions_語法對比_展示流暢語法優勢()
{
var users = userService.GetActiveUsers();
// 傳統 Assert 風格(不推薦)
/*
Assert.NotNull(users);
Assert.True(users.Count > 0);
Assert.True(users.All(u => u.IsActive));
Assert.Contains(users, u => u.Email.Contains("@example.com"));
*/
// AwesomeAssertions 流暢風格(推薦)
users.Should().NotBeNull()
.And.NotBeEmpty()
.And.AllSatisfy(u => u.IsActive.Should().BeTrue())
.And.Contain(u => u.Email.Contains("@example.com"));
// 優勢:更清楚的錯誤訊息、更好的可讀性、支援方法鏈結
}
[Fact]
public void ComplexObjectComparison_複雜物件比較_使用流暢語法()
{
var expectedUser = new User { Name = "John", Email = "john@example.com" };
var actualUser = userService.GetUser(1);
// 傳統風格需要多個 Assertions
/*
Assert.Equal(expectedUser.Name, actualUser.Name);
Assert.Equal(expectedUser.Email, actualUser.Email);
Assert.Equal(expectedUser.IsActive, actualUser.IsActive);
*/
// 流暢風格:一行搞定,錯誤訊息更詳細
actualUser.Should().BeEquivalentTo(expectedUser, options =>
options.Excluding(u => u.Id)
.Excluding(u => u.CreatedAt));
}
}
Fluent Assertions 商業化影響分析:
AwesomeAssertions 基礎功能掌握:
專案實戰應用技巧:
測試程式碼品質提升:
明天我們將深入探索 AwesomeAssertions 進階技巧與複雜情境應用,內容包括:
今天說明了 AwesomeAssertions 的基礎應用,讓我想起了軟體開發中的一個課題:工具選擇的平衡點。
Fluent Assertions 的商業化並非壞事,開源專案需要資金維持發展。但對企業而言,這提醒我們要有備案思維。
AwesomeAssertions 作為 Fluent Assertions 的 fork 版本,提供了完全相同的語法體驗,同時保持完全免費開源。
作為老派工程師,我學到的是:
測試 Assertions 是程式碼品質的基石。無論使用哪個工具,重要的是寫出表意清晰、易於維護、能夠建立信心的測試。語法相同的情況下,選擇開源免費的方案往往是更明智的決定。
明天我們將探索更進階的應用技巧,包括複雜物件比對、自訂 Assertions 擴展,以及動態欄位處理等高級功能。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」系列的第四天。明天我們將深入進階 Assertions 技巧的世界!